iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

Kotlin Lambda 從零開始系列 第 24 篇

Kotlin Lambda 從零開始 Day 24:chunked / windowed / zipWithNext — 區塊與滑動視窗

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會用 TDD 手刻 myChunked、myWindowed、myZipWithNext,搞懂區塊切割和滑動視窗的實作方式

Kotlin ↔ C# 對照表

Kotlin C# LINQ 備註
chunked(size) Chunk(size) (.NET 6+) C# 很晚才加入
windowed(size, step) 無直接對應
zipWithNext() 無直接對應 等同 windowed(2) 的特例

C# 到 .NET 6 才有 Chunk,而且沒有 windowed 和 zipWithNext。這兩個在資料分析場景很常用,Kotlin 從標準函式庫就支援

chunked vs windowed — 切塊 vs 滑動

先用圖理解差異

原始:[1, 2, 3, 4, 5]

chunked(2):[1,2] [3,4] [5]     — 不重疊,每塊獨立
windowed(3):[1,2,3] [2,3,4] [3,4,5]  — 有重疊,視窗滑動

chunked 把集合切成不重疊的區塊,像切蛋糕一刀一刀下去。最後一塊可能不到指定大小

windowed 用固定大小的視窗從頭滑到尾,每次移動 step 步。預設 step = 1,所以相鄰視窗會大量重疊

windowed 在時間序列、訊號處理是熟面孔。常見用法

  • moving average(移動平均):prices.windowed(7) { it.average() } — 七天平均
  • diff / 變化率:prices.zipWithNext { a, b -> b - a } — 相鄰兩天差值
  • n-gram:tokens.windowed(3) — 連續 3 個 token 當 feature

zipWithNext 就是把 windowed(2) 包成專用版,只處理「相鄰元素」這個高頻 idiom

employees 的薪水資料也能用來看相鄰變化。先按薪水排序,再用 zipWithNext 算每一階和下一階的差距

employees
    .sortedBy { it.salary }
    .zipWithNext { current, next -> next.name to (next.salary - current.salary) }
// [(Eve, 5000), (Charlie, 5000), (Bob, 7000), (Diana, 6000), (Alice, 7000), (Grace, 7000)]

如果要做固定大小的批次處理,chunked 也很自然

employees.chunked(3) { group -> group.map { it.name } }
// [[Alice, Bob, Charlie], [Diana, Eve, Frank], [Grace]]

C# 沒有 windowed,要做 moving average 得自己用 Skip + Take 配 Select,或借第三方函式庫。Kotlin 把這類 pattern 直接包進 stdlib,常見場景一行搞定

TDD 實作 myChunked

Red:先寫測試

@Test
fun `chunked into pairs`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myChunked(2)
    assertEquals(listOf(listOf(1, 2), listOf(3, 4), listOf(5)), result)
}

@Test
fun `chunked exact division`() {
    val numbers = listOf(1, 2, 3, 4, 5, 6)
    val result = numbers.myChunked(3)
    assertEquals(listOf(listOf(1, 2, 3), listOf(4, 5, 6)), result)
}

@Test
fun `chunked with transform`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myChunked(2) { it.sum() }
    assertEquals(listOf(3, 7, 5), result)
}

@Test
fun `chunked zero throws`() {
    assertThrows(IllegalArgumentException::class.java) {
        listOf(1, 2, 3).myChunked(0)
    }
}

帶 transform 的版本很實用。chunked(2) { it.sum() } 先切成 [1,2]、[3,4]、[5],再對每塊做 sum,得到 [3, 7, 5]。分頁處理、批次計算都會用到

Green:最小實作

fun <T> Iterable<T>.myChunked(size: Int): List<List<T>> {
    require(size > 0) { "Size $size must be greater than zero." }
    val result = ArrayList<List<T>>()
    val chunk = ArrayList<T>(size)
    for (element in this) {
        chunk.add(element)
        if (chunk.size == size) {
            result.add(ArrayList(chunk))
            chunk.clear()
        }
    }
    if (chunk.isNotEmpty()) {
        result.add(chunk)
    }
    return result
}

用一個暫存的 chunk 收集元素,滿了就加進結果然後清空。迴圈結束後如果 chunk 裡還有剩餘(最後一塊不滿),也加進結果

ArrayList(chunk) 是複製一份。如果直接 result.add(chunk) 再 chunk.clear(),加進去的那份也會被清空(因為是同一個物件)

帶 transform 的版本差別不大,只是把 result.add(ArrayList(chunk)) 換成 result.add(transform(ArrayList(chunk)))

這裡要先說明一件事,免得跟前面幾篇的內容不完全一樣,這篇帶 Lambda 參數的函式裡,myChunked 和 myWindowed 都不加 inline,只有等一下的 myZipWithNext 會加。stdlib 也是這樣分的。原因在後面「與 stdlib 原始碼比較」會講到的視窗重用機制 —— chunked / windowed 的 transform 版底層會把同一個緩衝區重複交給 transform,實作比表面上複雜,不是「一個迴圈裡直接呼叫 Lambda」那種適合 inline 的形狀。zipWithNext 沒有這層機制,就是單純的迴圈,所以標了 inline

fun <T, R> Iterable<T>.myChunked(size: Int, transform: (List<T>) -> R): List<R> {
    require(size > 0) { "Size $size must be greater than zero." }
    val result = ArrayList<R>()
    val chunk = ArrayList<T>(size)
    for (element in this) {
        chunk.add(element)
        if (chunk.size == size) {
            result.add(transform(ArrayList(chunk)))
            chunk.clear()
        }
    }
    if (chunk.isNotEmpty()) {
        result.add(transform(chunk))
    }
    return result
}

Refactor:往 stdlib 的寫法靠近

暫存一塊、滿了就送出、最後補上剩餘,這個迴圈已經是最終形狀,沒什麼好再改的。stdlib 走的是另一條路,把切塊的工作整個交給 windowed,這點留到後面「與 stdlib 原始碼比較」一節再對照

TDD 實作 myWindowed

Red:先寫測試

@Test
fun `windowed size 3`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myWindowed(3)
    assertEquals(listOf(listOf(1, 2, 3), listOf(2, 3, 4), listOf(3, 4, 5)), result)
}

@Test
fun `windowed with step 2`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myWindowed(3, step = 2)
    assertEquals(listOf(listOf(1, 2, 3), listOf(3, 4, 5)), result)
}

@Test
fun `windowed with partial windows`() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val result = numbers.myWindowed(3, step = 2, partialWindows = true)
    assertEquals(listOf(listOf(1, 2, 3), listOf(3, 4, 5), listOf(5)), result)
}

@Test
fun `windowed with transform for moving average`() {
    val numbers = listOf(1.0, 2.0, 3.0, 4.0, 5.0)
    val result = numbers.myWindowed(3) { it.average() }
    assertEquals(listOf(2.0, 3.0, 4.0), result)
}

@Test
fun `windowed zero size throws`() {
    assertThrows(IllegalArgumentException::class.java) {
        listOf(1, 2, 3).myWindowed(0)
    }
}

partialWindows 控制末尾不足 size 的視窗要不要保留。預設 false 丟掉,設 true 保留

跟 chunked 一樣,size 或 step 給 0(甚至負數)沒有意義,所以也補一個會丟 IllegalArgumentException 的測試,把例外路徑也測到

移動平均是 windowed 搭配 transform 的典型用法,windowed(3) { it.average() } 可以算出三日移動平均

使用 stdlib 的 transform 多載時要注意:傳入 Lambda 的視窗 List 只在該次呼叫期間有效,實作可能重複使用同一個 buffer。Lambda 應該直接算出結果;如果要把視窗保存到外部,先呼叫 toList() 複製。本篇的 myWindowed 為了讓實作容易閱讀,每個視窗都建立新的 ArrayList,這點與 stdlib 的最佳化不同

Green:最小實作

fun <T> Iterable<T>.myWindowed(
    size: Int,
    step: Int = 1,
    partialWindows: Boolean = false
): List<List<T>> {
    require(size > 0 && step > 0) { "Size $size and step $step must be greater than zero." }
    val source = this.toList()
    val result = ArrayList<List<T>>()
    var index = 0
    while (index < source.size) {
        val end = (index + size).coerceAtMost(source.size)
        val window = source.subList(index, end)
        if (window.size == size || partialWindows) {
            result.add(ArrayList(window))
        }
        index += step
    }
    return result
}

先 toList() 把 Iterable 轉成 List,因為 windowed 需要隨機存取(用 subList 取區間)

coerceAtMost 平常比較少用到,它是 Kotlin 的夾值函式:a.coerceAtMost(b) 表示「a 最多只能到 b,超過就取 b」,效果等同 minOf(a, b),只是寫成方法鏈的形式,接在算式後面讀起來比較順。同一組還有夾下限的 coerceAtLeast(day 22 的 dropLast 用它把負數夾成 0),以及上下限一起夾的 coerceIn

這裡用它是為了保護 subList。視窗滑到尾端時 index + size 會超出 source.size,直接拿去 subList 會丟 IndexOutOfBoundsException;夾成 source.size 之後,末尾拿到的就是一個長度不足的視窗。接下來判斷視窗滿了(size 等於要求的大小)就加進結果,不滿的話看 partialWindows 決定要不要加

跟 chunked 的差異在步進:chunked 的 step 等於 size(不重疊),windowed 的 step 預設 1(大量重疊)。其實 chunked(n) 等於 windowed(n, step = n, partialWindows = true)

帶 transform 的版本一樣只是多收一個轉換函式,把每個視窗先丟給 transform 再放進結果。移動平均的那個測試就是靠它通過

fun <T, R> Iterable<T>.myWindowed(
    size: Int,
    step: Int = 1,
    partialWindows: Boolean = false,
    transform: (List<T>) -> R
): List<R> {
    require(size > 0 && step > 0) { "Size $size and step $step must be greater than zero." }
    val source = this.toList()
    val result = ArrayList<R>()
    var index = 0
    while (index < source.size) {
        val end = (index + size).coerceAtMost(source.size)
        val window = source.subList(index, end)
        if (window.size == size || partialWindows) {
            result.add(transform(ArrayList(window)))
        }
        index += step
    }
    return result
}

Refactor:往 stdlib 的寫法靠近

stdlib 在這裡多做了一步:先依 size、step 算出大概會有幾個視窗,用這個數字一次把 ArrayList 的容量開好,省掉迴圈中反覆擴容、搬資料的成本。容量怎麼算留到「與 stdlib 原始碼比較」一節細說,這裡先知道「視窗數是可以預先算出來的」就好

TDD 實作 myZipWithNext

Red:先寫測試

@Test
fun `zipWithNext pairs`() {
    val numbers = listOf(1, 2, 3, 4)
    val result = numbers.myZipWithNext()
    assertEquals(listOf(1 to 2, 2 to 3, 3 to 4), result)
}

@Test
fun `zipWithNext with transform for differences`() {
    val numbers = listOf(1, 3, 6, 10)
    val result = numbers.myZipWithNext { a, b -> b - a }
    assertEquals(listOf(2, 3, 4), result)
}

@Test
fun `zipWithNext single element returns empty`() {
    val single = listOf(42)
    val result = single.myZipWithNext()
    assertEquals(emptyList<Pair<Int, Int>>(), result)
}

zipWithNext { a, b -> b - a } 算相鄰元素的差。這在分析時間序列資料(每日變化量、成長率)的時候很方便

Green:最小實作

fun <T> Iterable<T>.myZipWithNext(): List<Pair<T, T>> {
    val iterator = this.iterator()
    if (!iterator.hasNext()) {
        return emptyList()
    }
    val result = ArrayList<Pair<T, T>>()
    var current = iterator.next()
    while (iterator.hasNext()) {
        val next = iterator.next()
        result.add(current to next)
        current = next
    }
    return result
}

用 iterator 追蹤「目前的」和「下一個」元素。每次疊代把 current 和 next 配對,然後 next 變成新的 current

跟 day 13 的 zip 概念一樣是配對,但 zip 是兩個不同集合的元素配對,zipWithNext 是同一個集合的相鄰元素配對

帶 transform 的版本不用先湊出 Pair 再轉換,直接把相鄰的 current 和 next 丟給 transform。zipWithNext { a, b -> b - a } 那個算相鄰差的測試就是走這條路

inline fun <T, R> Iterable<T>.myZipWithNext(transform: (a: T, b: T) -> R): List<R> {
    val iterator = this.iterator()
    if (!iterator.hasNext()) {
        return emptyList()
    }
    val result = ArrayList<R>()
    var current = iterator.next()
    while (iterator.hasNext()) {
        val next = iterator.next()
        result.add(transform(current, next))
        current = next
    }
    return result
}

Refactor:往 stdlib 的寫法靠近

兩個多載各寫了一份幾乎相同的迴圈,差別只在「配對之後做什麼」。既然帶 transform 的版本更通用,不帶 transform 的那個就可以委派過去,傳入「把兩個元素湊成 Pair」當轉換

fun <T> Iterable<T>.myZipWithNext(): List<Pair<T, T>> {
    return myZipWithNext { a, b -> a to b }
}

迴圈只剩一份,之後要調整疊代邏輯也只要改一個地方。stdlib 正是這樣安排的,原始碼留到後面「與 stdlib 原始碼比較」一節再看

實際應用場景

// 分頁:每頁 20 筆
val pages = items.chunked(20)

// 三日移動平均
val movingAvg = prices.windowed(3) { it.average() }

// 檢查相鄰元素是否遞增
val isIncreasing = numbers.zipWithNext { a, b -> a < b }.all { it }

與 stdlib 原始碼比較

原始碼位置:kotlin.collections 的 _Collections.kt

stdlib 的 chunked 沒有自己一套迴圈,一行就轉呼叫 windowed

public fun <T> Iterable<T>.chunked(size: Int): List<List<T>> {
    return windowed(size, size, partialWindows = true)
}

chunked(n) 就是 windowed(n, step = n, partialWindows = true)。我們分開實作是為了教學,把切塊邏輯講清楚;但 stdlib 直接重用 windowed,省掉一份重複的迴圈

前面 myChunked 的 Refactor 沒辦法做這件事,因為當時 myWindowed 還沒寫。現在兩個都有了,myChunked 就可以改寫

fun <T> Iterable<T>.myChunked(size: Int): List<List<T>> {
    return myWindowed(size, size, partialWindows = true)
}

myChunked 那組測試照樣會過,切塊邏輯整個由 myWindowed 負責。要留哪一個版本要看取捨,手寫迴圈那版讀起來直接,不必先懂 windowed 的三個參數;委派這版少一份重複,windowed 改了 chunked 自動跟上

windowed 這邊的差別在容量預估。我們的版本是 ArrayList() 開預設容量,邊跑邊擴容;stdlib 在走 RandomAccess List 這條路徑時,會先算好結果會有幾個視窗,一次把容量開到位。它的公式是

val resultCapacity = thisSize / step + if (thisSize % step == 0) 0 else 1

也就是「整個長度除以 step,除不盡再加一」。注意這個數字是「會起頭幾個視窗」(每個 step 起一個視窗),不是「完整視窗有幾個」。為什麼用起頭數而不是完整視窗數?因為開了 partialWindows 時末尾不足的視窗也會佔一格,用這個上界開容量最穩。算對容量,ArrayList 就不必在迴圈裡反覆擴容、搬資料

zipWithNext 這邊,stdlib 的不帶 transform 版本就是帶 transform 版本的特例

public fun <T> Iterable<T>.zipWithNext(): List<Pair<T, T>> {
    return zipWithNext { a, b -> a to b }
}

主要邏輯放在帶 transform 的多載,不帶 transform 的版本只套用「湊成 Pair」這個預設轉換,也就是前面 Refactor 說的委派寫法。至於它跟 windowed 的關係,zipWithNext() 等於 windowed(2) { (a, b) -> a to b },只是用 iterator 直接走相鄰元素,不必先 toList(),對只能單向疊代的序列更省

第六部分回顧

切片篇到這裡結束

take/drop(固定數量) → takeWhile/dropWhile(條件式) → chunked/windowed/zipWithNext(區塊與視窗)

從最簡單的「取前 n 個」到「用滑動視窗掃描」,複雜度逐步升高但核心邏輯都是在 for 迴圈裡控制「要不要加進結果」

本部分 API 速查

手刻函式 篇號 用途
myTake day 22 取前 n 個元素
myTakeLast day 22 取後 n 個元素
myDrop day 22 丟掉前 n 個,回傳剩下的
myDropLast day 22 丟掉後 n 個,回傳剩下的
myTakeWhile day 23 從頭取到第一個不符條件為止
myDropWhile day 23 從頭丟到第一個不符條件,留下其餘
myChunked day 24 切成不重疊的固定大小區塊
myWindowed day 24 固定大小視窗滑動掃描,可設步進
myZipWithNext day 24 相鄰元素兩兩配對

小結

chunked 切塊、windowed 滑動視窗、zipWithNext 配對相鄰,三個函式底層其實同源,chunked 和 zipWithNext 都能用 windowed 表達。看懂這層關係,往後遇到區塊或視窗的需求就知道該往哪個方向想

day 25 進入集合運算篇。distinct / distinctBy 去除重複元素,靠的是 HashSet 的特性

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 23:takeWhile / dropWhile — 條件式切片
下一篇
Kotlin Lambda 從零開始 Day 25:distinct / distinctBy — 去除重複
系列文
Kotlin Lambda 從零開始 共 36 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言